MongoDB 聚合 aggregate
聚合操作处理多个文档并返回计算结果。您可以使用聚合操作来:
-
将多个文档中的值组合在一起。
-
对分组数据执行操作,返回单一结果。
-
分析一段时间内的数据变化。
若要执行聚合操作,您可以使用:
-
聚合管道,这是执行聚合的首选方法。
-
单一目的聚合方法,这些方法很简单,但缺乏聚合管道的功能。
聚合管道
聚合管道由一个或多个处理文档的阶段组成:
- 每个阶段对输入文档执行一个操作。例如,某个阶段可以过滤文档、对文档进行分组并计算值。
- 从一个阶段输出的文档将传递到下一阶段。
- 一个聚合管道可以返回针对文档组的结果。例如,返回总值、平均值、最大值和最小值。
如使用通过聚合管道更新中显示的阶段,则可以通过聚合管道更新文档。
使用
db.collection.aggregate()
方法运行的聚合管道不会修改集合中的 文档,除非管道包含$merge
或$out
阶段。
例一
本部分展示了使用以下披萨 orders
集合的聚合管道示例:
db.orders.insertMany( [
{ _id: 0, name: "Pepperoni", size: "small", price: 19,
quantity: 10, date: ISODate( "2021-03-13T08:14:30Z" ) },
{ _id: 1, name: "Pepperoni", size: "medium", price: 20,
quantity: 20, date : ISODate( "2021-03-13T09:13:24Z" ) },
{ _id: 2, name: "Pepperoni", size: "large", price: 21,
quantity: 30, date : ISODate( "2021-03-17T09:22:12Z" ) },
{ _id: 3, name: "Cheese", size: "small", price: 12,
quantity: 15, date : ISODate( "2021-03-13T11:21:39.736Z" ) },
{ _id: 4, name: "Cheese", size: "medium", price: 13,
quantity:50, date : ISODate( "2022-01-12T21:23:13.331Z" ) },
{ _id: 5, name: "Cheese", size: "large", price: 14,
quantity: 10, date : ISODate( "2022-01-12T05:08:13Z" ) },
{ _id: 6, name: "Vegan", size: "small", price: 17,
quantity: 10, date : ISODate( "2021-01-13T05:08:13Z" ) },
{ _id: 7, name: "Vegan", size: "medium", price: 18,
quantity: 10, date : ISODate( "2021-01-13T05:10:13Z" ) }
] )
以下聚合管道示例包含两个阶段,并返回按披萨名称分组后,各款中号披萨的总订单数量:
db.orders.aggregate( [
// Stage 1: Filter pizza order documents by pizza size
{
$match: { size: "medium" }
},
// Stage 2: Group remaining documents by pizza name and calculate total quantity
{
$group: { _id: "$name", totalQuantity: { $sum: "$quantity" } }
}
] )
$match
阶段:
- 从披萨订单文档过滤出
size
为medium
的披萨。 - 将剩余文档传递到
$group
阶段。
$group
阶段:
- 按披萨
name
对剩余文档进行分组。 - 使用
$sum
计算每种披萨name
的总订单quantity
。总数存储在聚合管道返回的totalQuantity
字段中。
示例输出:
[
{ _id: 'Cheese', totalQuantity: 50 },
{ _id: 'Vegan', totalQuantity: 10 },
{ _id: 'Pepperoni', totalQuantity: 20 }
]
例二
以下示例计算了两个日期之间的披萨订单总额和平均订单数量:
db.orders.aggregate( [
// Stage 1: Filter pizza order documents by date range
{
$match:
{
"date": { $gte: new ISODate( "2020-01-30" ), $lt: new ISODate( "2022-01-30" ) }
}
},
// Stage 2: Group remaining documents by date and calculate results
{
$group:
{
_id: { $dateToString: { format: "%Y-%m-%d", date: "$date" } },
totalOrderValue: { $sum: { $multiply: [ "$price", "$quantity" ] } },
averageOrderQuantity: { $avg: "$quantity" }
}
},
// Stage 3: Sort documents by totalOrderValue in descending order
{
$sort: { totalOrderValue: -1 }
}
] )
$match
阶段:
$group
阶段:
- 使用
$dateToString
按日期对文档进行分组。 - 对于每个群组,计算:
- 将分组的文档传递到
$sort
阶段。
$sort
阶段:
- 按每组的总订单值以降序对文档进行排序 (
-1
)。 - 返回排序文档。
示例输出:
[
{ _id: '2022-01-12', totalOrderValue: 790, averageOrderQuantity: 30 },
{ _id: '2021-03-13', totalOrderValue: 770, averageOrderQuantity: 15 },
{ _id: '2021-03-17', totalOrderValue: 630, averageOrderQuantity: 30 },
{ _id: '2021-01-13', totalOrderValue: 350, averageOrderQuantity: 10 }
]
单一目的聚合方法
单一目的聚合方法聚合单个集合中的文档。这些方法很简单,但缺乏聚合管道的功能。
方法 | 说明 |
---|---|
db.collection.estimatedDocumentCount() | 返回 集合或视图中文档的近似数量。 |
db.collection.count() | 返回集合或视图中文档的数量。 |
db.collection.distinct() | 返回具有指定字段的不同值的文档数组。 |
字段路径
您可以使用字段路径(Field Path)表达式访问权限输入文档中的字段
要指定字段路径,加上美元符号$
。
可以在以下使用案例中使用字段路径:
嵌套字段
以下示例使用Atlas样本数据库中的planets集合。此集合中的每个文档都具有以下结构:
{
_id: new ObjectId("6220f6b78a733c51b416c80e"),
name: "Uranus",
orderFromSun: 7,
hasRings: true,
mainAtmosphere: [ "H2", "He", "CH4" ],
surfaceTemperatureC: { min: null, max: null, mean: -197.2 }
}
要在 字段中指定嵌套字段 mean
,请使用带有美元符号 "field.nestedField"
的点表示法:
db.planets.aggregate( [
{
$project: {
nested_field: "$surfaceTemperatureC.mean"
}
}
] )
以下是返回文档的示例:
{ _id: ObjectId('6220f6b78a733c51b416c80e'), nested_field: -197.2 }
嵌套字段数组
您可以在字段路径(Field Path)中使用点表示法来访问权限嵌套在数组中的字段。
示例,考虑一个包含 instock
字段的 products
集合。 instock
字段包含一个嵌套 warehouse
字段的数组。
db.products.insertMany( [
{ item: "journal", instock: [ { warehouse: "A"}, { warehouse: "C" } ] },
{ item: "notebook", instock: [ { warehouse: "C" } ] },
{ item: "paper", instock: [ { warehouse: "A" }, { warehouse: "B" } ] },
{ item: "planner", instock: [ { warehouse: "A" }, { warehouse: "B" } ] },
{ item: "postcard", instock: [ { warehouse: "B" }, { warehouse: "C" } ] }
] )
以下聚合管道使用 $instock.warehouse
访问权限嵌套的 warehouse
字段。
db.products.aggregate( [
{
$project: {
item: 1,
warehouses: "$instock.warehouse"
}
}
] )
在此示例中,$instock.warehouse
输出每个文档的嵌套 warehouse
字段中的值的数组。管道返回以下文档:
[
{
_id: ObjectId('6740b55e33b29cf6b1d884f7'),
item: "journal",
warehouses: [ "A", "C" ]
},
{
_id: ObjectId('6740b55e33b29cf6b1d884f8'),
item: "notebook",
warehouses: [ "C" ]
},
{
_id: ObjectId('6740b55e33b29cf6b1d884f9'),
item: "paper",
warehouses: [ "A", "B" ]
},
{
_id: ObjectId('6740b55e33b29cf6b1d884fa'),
item: "planner",
warehouses: [ "A", "B" ]
},
{
_id: ObjectId('6740b55e33b29cf6b1d884fb'),
item: "postcard",
warehouses: [ "B", "C" ]
}
]
嵌套数组的数组
还可以在字段路径(Field Path)中使用带有美元符号$
的点表示法来访问权限嵌套数组中的数组。
此示例使用包含以下文档的 fruits
集合:
db.fruits.insertOne(
{
_id: ObjectId("5ba53172ce6fa2fcfc58e0ac"),
inventory: [
{
apples: [
"macintosh",
"golden delicious",
]
},
{
oranges: [
"mandarin",
]
},
{
apples: [
"braeburn",
"honeycrisp",
]
}
]
}
)
集合中的文档包含一个 inventory
数组嵌数组
考虑以下聚合管道:
db.fruits.aggregate( [
{ $project:
{ all_apples: "$inventory.apples" } }
] )
管道返回以下文档:
{
_id: ObjectId('5ba53172ce6fa2fcfc58e0ac'),
all_apples: [
[ "macintosh", "golden delicious" ],
[ "braeburn", "honeycrisp" ]
]
}
优化
聚合管道操作包含一个优化阶段,该阶段会尝试重塑管道以提高性能。
要查看优化器如何转换特定的聚合管道,请在 db.collection.aggregate()
方法中添加 explain
选项。
优化可能因版本而异。
优化的管道不宜手动运行。原始管 道和优化管道返回相同的结果。
投影优化
聚合管道可确定是否只需文档中的部分字段即可获取结果。如果是,管道则仅会使用这些字段,从而减少通过管道传递的数据量。
当使用 $project
阶段时,它通常应该是管道的最后一个阶段,用于指定要返回给客户端的字段。
在管道的开头或中间使用 $project
阶段来减少传递到后续管道阶段的字段数量不太可能提高性能,因为数据库会自动执行此优化。
管道序列优化
($project
、$unset
、$addFields
或 $set
)+ $match
序列优化
如果聚合管道包含投影阶段 ($addFields
、$project
、$set
或$unset
),且其后跟随 $match
阶段,MongoDB 会将 $match
阶段中无需使用投影阶段计算的值的所有过滤器移动到投影前的新的 $match
阶段。
如果聚合管道包含多个投影或 $match
阶段,MongoDB 会对每个 $match
阶段执行此优化,将每个 $match
过滤器移到过滤器不依赖的所有投影阶段之前。
考虑包含以下阶段的管道示例:
{
$addFields: {
maxTime: { $max: "$times" },
minTime: { $min: "$times" }
}
},
{
$project: {
_id: 1,
name: 1,
times: 1,
maxTime: 1,
minTime: 1,
avgTime: { $avg: ["$maxTime", "$minTime"] }
}
},
{
$match: {
name: "Joe Schmoe",
maxTime: { $lt: 20 },
minTime: { $gt: 5 },
avgTime: { $gt: 7 }
}
}
优化器会将 $match
阶段分解为四个单独的过滤器,每个过滤器对应 $match
查询文档中的一个键。然后,优化器会将每个过滤器移至尽可能多的投影阶段之前,从而按需创建新的 $match
阶段。
在此示例中,优化器将自动生成以下优化后的管道:
{ $match: { name: "Joe Schmoe" } },
{ $addFields: {
maxTime: { $max: "$times" },
minTime: { $min: "$times" }
} },
{ $match: { maxTime: { $lt: 20 }, minTime: { $gt: 5 } } },
{ $project: {
_id: 1, name: 1, times: 1, maxTime: 1, minTime: 1,
avgTime: { $avg: ["$maxTime", "$minTime"] }
} },
{ $match: { avgTime: { $gt: 7 } } }
-
$match
筛选器{ avgTime: { $gt: 7 } }
依赖$project
阶段来计算avgTime
字段。$project
阶段是该管道中的最后一个投影阶段,因此avgTime
上的$match
筛选器无法移动。 -
maxTime
和minTime
字段在$addFields
阶段计算,但不依赖$project
阶段。优化器已为这些字段上的筛选器创建一个新的$match
阶段,并将其置于$project
阶段之前。 -
$match
筛选器{ name: "Joe Schmoe" }
不使用在$project
或$addFields
阶段计算的任何值,因此它在这两个投影阶段之前移到了新的$match
阶段。 -
优化后,筛选器
{ name: "Joe Schmoe" }
在管道开始时会处于$match
阶段。此举还允许聚合在最初查询该集合时使用针对name
字段的索引。
$sort
+ $match
序列优化
当序列中的 $sort
后面是 $match
时,$match
会在 $sort
之前移动,以最大限度地减少要排序的对象数量。例如,如果管道由以下阶段组成:
{ $sort: { age : -1 } },
{ $match: { status: 'A' } }
在优化阶段,优化器会将序列转换为以下内容:
{ $match: { status: 'A' } },
{ $sort: { age : -1 } }
$redact
+ $match
序列优化
如果可能,当管道有 $redact
阶段紧接着 $match
阶段时,聚合有时可以在$redact
阶段之前添加$match
阶段的一部分。如果添加的 $match
阶段位于管道的开头,则聚合可以使用索引并查询集合以限制进入管道的文档数量。有关更多信息,请参阅使用索引和文档过滤器提高性能。
例如,如果管道由以下阶段组成:
{ $redact: { $cond: { if: { $eq: [ "$level", 5 ] }, then: "$$PRUNE", else: "$$DESCEND" } } },
{ $match: { year: 2014, category: { $ne: "Z" } } }
优化器可以在 $redact
阶段之前添加相同的 $match
阶段:
{ $match: { year: 2014 } },
{ $redact: { $cond: { if: { $eq: [ "$level", 5 ] }, then: "$$PRUNE", else: "$$DESCEND" } } },
{ $match: { year: 2014, category: { $ne: "Z" } } }
$project
/$unset
+ $skip
序列优化
如果序列中的 $project
或 $unset
后面是 $skip
,则 $skip
在 $project
之前移动。例如,如果管道由以下阶段组成 :
{ $sort: { age : -1 } },
{ $project: { status: 1, name: 1 } },
{ $skip: 5 }
在优化阶段,优化器会将序列转换为以下内容:
{ $sort: { age : -1 } },
{ $skip: 5 },
{ $project: { status: 1, name: 1 } }
此操作可让排序操作在推进时仅维护前 n
个结果,其中 n
为指定的限制,而 MongoDB 仅需要在内存中存储 n
个项目
$limit
+ $limit
合并
当 $limit
紧随另一个 $limit
时,这两个阶段可以合并为一个 $limit
,以两个初始限额中较小的为合并后的限额。
{ $limit: 100 },
{ $limit: 10 }
第二个 $limit
阶段可以合并到第一个 $limit
阶段:
{ $limit: 10 }
$skip
+ $skip
合并
当 $skip
紧随在另一个 $skip
之后时,这两个阶段可以合并为一个 $skip
,其中的跳过数量是两个初始跳过数量的总和。
{ $skip: 5 },
{ $skip: 2 }
然后第二个 $skip
阶段可以合并到第一个 $skip
阶段:
{ $skip: 7 }
$sort
+ $skip
+ $limit
顺序
如果 $sort
与 $limit
阶段之间存在一个 $skip
阶段,MongoDB 会将 $limit
合并到 $sort
阶段,并将 $limit
的值增加 $skip
的数量。
管道的阶段序列为:首先为 $sort
,其次为 $skip
,再次为 $limit
:
{ $sort: { age : -1 } },
{ $skip: 10 },
{ $limit: 5 }
优化器执行 $sort
+ $limit
合并以将此序列转换为以下内容:
{
"$sort" : {
"sortKey" : {
"age" : -1
},
"limit" : NumberLong(15)
}
},
{
"$skip" : NumberLong(10)
}
重新排序后,MongoDB 增加了 $limit
的数量。
$match
+ $match
合并
当 $match
紧随另一个 $match
之后时,这两个阶段可以合并为一个 $match
,用 $and
将条件组合在一起。例如,一个管道包含以下序列:
{ $match: { year: 2014 } },
{ $match: { status: "A" } }
然后第二个 $match
阶段可合并到第一个 $match
阶段并形成一个 $match
阶段
{ $match: { $and: [ { "year" : 2014 }, { "status" : "A" } ] } }